9장. 데이터를 표현하는 클래스
프로그램은 결국 “데이터를 주고받는 일“입니다.
사용자 정보, 상품 정보, 주문 내역처럼
여러 값을 하나로 묶어서 다뤄야 할 때가 많습니다.
이렇게 “데이터를 담는 것이 주된 목적“인 클래스를
코틀린은 아주 간단하게 만들 수 있습니다.
이 장에서는 데이터를 표현하는 특별한 클래스인
데이터 클래스(data class)를 배웁니다.
그리고 이 데이터 클래스가
백엔드에서 실제로 어떻게 쓰이는지도 함께 살펴봅니다.
자바에서 길고 번거로웠던 코드가
코틀린에서 얼마나 짧아지는지 직접 확인해 봅시다.
9.1 Data Class
데이터를 담는 클래스가 필요한 이유
먼저 하나의 상황을 떠올려 봅시다.
사용자 한 명의 정보를 다룬다고 해 보겠습니다.
이름과 나이, 이메일이 필요합니다.
이 세 값은 따로 떨어져 있으면 관리하기 어렵습니다.
그래서 하나로 묶어 주는 것이 좋습니다.
이렇게 여러 값을 하나로 묶는 도구가
바로 클래스(class)입니다.
(클래스의 기본은 앞 장에서 다뤘습니다.)
일반 클래스로 사용자를 표현하면 이렇습니다.
class User(
val name: String,
val age: Int,
val email: String,
)
이 클래스는 값을 담는 데는 문제가 없습니다.
하지만 실제로 써 보면 불편한 점이 드러납니다.
val user = User("홍길동", 20, "hong@example.com")
println(user) // 내용이 안 보이고 이상한 값이 나옴
출력 결과는 이렇게 나옵니다.
User@1b6d3586
우리가 기대한 것은 “이름은 홍길동, 나이는 20“인데,
알 수 없는 기호만 찍혔습니다.
값이 같은지 비교해도 문제가 생깁니다.
val a = User("홍길동", 20, "hong@example.com")
val b = User("홍길동", 20, "hong@example.com")
println(a == b) // false
두 사용자는 내용이 완전히 똑같은데도
“다르다(false)“고 나옵니다.
내용이 같으면 같다고 나오길 바라는데
일반 클래스는 그렇게 동작하지 않습니다.
data class 만들기
이 문제를 한 번에 해결해 주는 것이
데이터 클래스(data class)입니다.
데이터 클래스는 “데이터를 담는 것이 주 목적인 클래스“입니다.
만드는 방법은 아주 간단합니다.
앞에 data라는 단어 하나만 붙이면 됩니다.
data class User(
val name: String,
val age: Int,
val email: String,
)
class 앞에 data를 붙인 것이 전부입니다.
이 단어 하나를 붙이면
코틀린이 유용한 기능들을 자동으로 만들어 줍니다.
방금 불편했던 문제들이
모두 알아서 해결됩니다.
하나씩 확인해 보겠습니다.
toString: 내용을 보기 좋게 출력하기
먼저 출력부터 확인해 봅시다.
val user = User("홍길동", 20, "hong@example.com")
println(user)
이번에는 결과가 이렇게 나옵니다.
User(name=홍길동, age=20, email=hong@example.com)
클래스 이름과 함께
안에 담긴 값이 그대로 보입니다.
이렇게 객체를 사람이 읽기 좋은 글자로 바꿔 주는 기능을toString이라고 부릅니다.
이름 그대로 “문자열(string)로 바꾼다(to)“는 뜻입니다.
일반 클래스라면 이 기능을 직접 만들어야 했지만,
데이터 클래스는 자동으로 만들어 줍니다.
디버깅이나 로그를 남길 때
값을 바로 눈으로 확인할 수 있어 아주 편리합니다.
equals: 내용이 같은지 비교하기
이번에는 비교를 확인해 봅시다.
val a = User("홍길동", 20, "hong@example.com")
val b = User("홍길동", 20, "hong@example.com")
println(a == b) // true
이번에는 “같다(true)“가 나옵니다.
내용이 같으면 같다고 판단하는 이 기능을equals라고 부릅니다.
(equals는 “같다“는 뜻의 영어 단어입니다.)
2.5절에서 코틀린의 ==가
“값이 같은가“를 비교한다고 배웠습니다.
데이터 클래스는 이 == 비교가
“안에 담긴 값이 모두 같은가“를 보도록 만들어 줍니다.
즉, 이름과 나이와 이메일이 모두 같으면
같은 사용자로 취급합니다.
데이터 클래스는
“겉모습이 아니라 내용으로” 같음을 판단합니다.
hashCode: 값을 빠르게 찾기 위한 번호
equals와 늘 짝을 이루는 기능이hashCode입니다.
hashCode는 객체를 대표하는 “번호표” 같은 것입니다.
비유하자면 이렇습니다.
도서관에서 책마다 청구기호가 붙어 있으면
원하는 책을 훨씬 빨리 찾을 수 있습니다.hashCode는 그 청구기호에 해당합니다.
컴퓨터가 수많은 데이터 중에서
원하는 값을 빠르게 찾을 때 이 번호를 활용합니다.
여기서 중요한 규칙이 하나 있습니다.
내용이 같으면(
equals가 true면)hashCode도 반드시 같아야 합니다.
이 규칙을 어기면
데이터를 저장하고 찾는 과정에서 오류가 생깁니다.
자바에서는 이 규칙을 지키려고equals와 hashCode를 항상 함께 신경 써야 했습니다.
코틀린의 데이터 클래스는
이 둘을 규칙에 맞게 자동으로 만들어 줍니다.
우리가 실수할 여지 자체를 없애 주는 것입니다.
자바의 POJO와 비교하기
자바를 조금 아는 분이라면
지금 이 편리함이 더 크게 느껴질 것입니다.
자바에서는 데이터를 담는 클래스를
POJO라고 불렀습니다.
POJO는 Plain Old Java Object,
우리말로 “평범하고 오래된 자바 객체“입니다.
그냥 값을 담는 단순한 클래스라는 뜻입니다.
문제는 이 단순한 클래스를 제대로 만들려면
코드가 아주 길어졌다는 점입니다.
같은 User를 자바로 쓰면 대략 이렇습니다.
// 자바
public class User {
private final String name;
private final int age;
private final String email;
public User(String name, int age, String email) {
this.name = name;
this.age = age;
this.email = email;
}
public String getName() { return name; }
public int getAge() { return age; }
public String getEmail() { return email; }
@Override
public boolean equals(Object o) {
// ... 값 하나하나 비교하는 긴 코드
}
@Override
public int hashCode() {
// ... 번호를 계산하는 긴 코드
}
@Override
public String toString() {
// ... 문자열로 바꾸는 긴 코드
}
}
값은 세 개뿐인데
코드는 수십 줄에 달합니다.
코틀린은 같은 일을 이 한 줄로 끝냅니다.
data class User(val name: String, val age: Int, val email: String)
무엇이 자동으로 만들어지는지 정리하면 이렇습니다.
| 기능 | 하는 일 |
|---|---|
toString | 값을 보기 좋은 문자열로 바꿔 준다 |
equals | 내용이 같은지 비교해 준다 |
hashCode | 값을 빠르게 찾기 위한 번호를 만들어 준다 |
copy | 기존 객체를 복사해 새 객체를 만들어 준다 |
componentN | 값을 하나씩 꺼낼 수 있게 해 준다 |
이 표의 아래 두 가지,copy와 componentN은 다음 절들에서 자세히 다룹니다.
9.2 copy
기존 객체를 기반으로 새로운 객체 만들기
데이터를 다루다 보면
“거의 같은데 한 부분만 다른” 객체가 필요할 때가 많습니다.
예를 들어 사용자의 나이만 한 살 늘리고 싶다고 해 봅시다.
나머지 정보는 그대로 두고 싶습니다.
이럴 때 값을 전부 다시 적으면 번거롭습니다.
val user = User("홍길동", 20, "hong@example.com")
// 나이만 바꾸고 싶은데 전부 다시 적어야 함
val older = User("홍길동", 21, "hong@example.com")
이름과 이메일은 똑같은데
다시 적느라 실수하기도 쉽습니다.
이럴 때 쓰는 것이 copy입니다.
copy는 이름 그대로
“기존 객체를 복사해서 새 객체를 만드는” 기능입니다.
바꾸고 싶은 값만 콕 집어서 적으면 됩니다.
val user = User("홍길동", 20, "hong@example.com")
val older = user.copy(age = 21)
println(older)
// User(name=홍길동, age=21, email=hong@example.com)
age = 21만 적었더니
나이만 바뀌고 나머지는 그대로 복사되었습니다.
적지 않은 값은 원래 객체의 값을
그대로 가져옵니다.
여러 값을 한 번에 바꿀 수도 있습니다.
val changed = user.copy(age = 21, email = "new@example.com")
println(changed)
// User(name=홍길동, age=21, email=new@example.com)
바꾸고 싶은 것만 적으면 되니
코드가 짧고 실수가 줄어듭니다.
불변 객체와 copy
여기서 한 가지 짚고 넘어갈 점이 있습니다.
copy는 원래 객체를 바꾸지 않습니다.
항상 새로운 객체를 만들어 돌려줍니다.
직접 확인해 봅시다.
val user = User("홍길동", 20, "hong@example.com")
val older = user.copy(age = 21)
println(user) // User(name=홍길동, age=20, ...) ← 그대로
println(older) // User(name=홍길동, age=21, ...) ← 새 객체
older를 만들었지만
원래 user는 전혀 바뀌지 않았습니다.
이 방식은 2장에서 배운
불변성(immutability)과 잘 어울립니다.
2장에서 이렇게 배웠습니다.
값은 되도록 바뀌지 않게 만들자.
데이터 클래스의 값을 val로 만들면
객체를 만든 뒤에는 내용을 바꿀 수 없습니다.
그런데 값을 조금 바꿔야 할 때가 있습니다.
이때 원래 객체를 건드리는 대신,copy로 “바뀐 새 객체“를 만드는 것입니다.
바뀌지 않는 객체를
불변 객체(immutable object)라고 부릅니다.
정리하면 이렇습니다.
- 원래 객체는 그대로 둔다 (안전하다)
- 바뀐 값은 새 객체로 표현한다 (
copy)
이 방식의 장점은 분명합니다.
어떤 객체가 갑자기 바뀔까 걱정할 필요가 없습니다.
특히 여러 곳에서 같은 객체를 함께 쓸 때,
불변 객체는 예상치 못한 오류를 크게 줄여 줍니다.
copy는 “안전하게 값을 바꾸는” 방법입니다.
원본은 지키고, 변화는 새 객체에 담습니다.
9.3 구조 분해
값을 하나씩 꺼내기
데이터 클래스에 담긴 값을
따로따로 꺼내 쓰고 싶을 때가 있습니다.
보통은 이렇게 하나씩 꺼냅니다.
val user = User("홍길동", 20, "hong@example.com")
val name = user.name
val age = user.age
val email = user.email
틀린 방법은 아니지만
세 줄이나 필요합니다.
코틀린에는 이것을 한 줄로 줄여 주는
편리한 기능이 있습니다.
바로 구조 분해(destructuring)입니다.
이름이 어렵지만 뜻은 간단합니다.
하나로 묶인 객체를 “풀어서”
여러 변수에 한 번에 나눠 담는 것입니다.
Data Class 구조 분해
데이터 클래스를 구조 분해하면 이렇습니다.
val user = User("홍길동", 20, "hong@example.com")
val (name, age, email) = user
println(name) // 홍길동
println(age) // 20
println(email) // hong@example.com
괄호 안에 변수 이름을 나열했더니
값이 순서대로 하나씩 담겼습니다.
세 줄이 한 줄로 줄었습니다.
여기서 중요한 것은 순서입니다.
데이터 클래스에 적은 순서대로 값이 담깁니다.
data class User(
val name: String, // 첫 번째
val age: Int, // 두 번째
val email: String, // 세 번째
)
그래서 위 예시에서name에는 이름이, age에는 나이가,email에는 이메일이 들어간 것입니다.
일부만 꺼내고 싶다면
필요 없는 자리는 밑줄(_)로 건너뛸 수 있습니다.
val user = User("홍길동", 20, "hong@example.com")
val (name, _, email) = user // 나이는 건너뜀
println(name) // 홍길동
println(email) // hong@example.com
componentN: 구조 분해가 동작하는 원리
구조 분해는 어떻게 값을 순서대로 꺼낼까요?
그 비밀은 componentN이라는 기능에 있습니다.
데이터 클래스를 만들면 코틀린이
값마다 번호가 붙은 함수를 자동으로 만들어 줍니다.
component1(): 첫 번째 값component2(): 두 번째 값component3(): 세 번째 값
N 자리에 순서 번호가 들어간다고 생각하면 됩니다.
앞의 구조 분해 코드는
사실 다음과 같이 동작합니다.
val name = user.component1() // 첫 번째 값
val age = user.component2() // 두 번째 값
val email = user.component3() // 세 번째 값
우리가 val (name, age, email) = user라고 적으면,
코틀린이 속으로 이 componentN 함수들을 대신 불러 줍니다.
이 함수들도 데이터 클래스가
자동으로 만들어 주는 기능 중 하나입니다.
(9.1절 표의 componentN이 바로 이것입니다.)
원리까지 외울 필요는 없습니다.
“순서대로 꺼내진다“는 점만 기억하면 충분합니다.
9.4 백엔드 DTO 만들기
이제 데이터 클래스가
백엔드에서 실제로 어떻게 쓰이는지 살펴봅시다.
여기서 아주 자주 등장하는 개념이
DTO입니다.
DTO는 Data Transfer Object,
우리말로 “데이터 전송 객체“입니다.
이름 그대로
“데이터를 실어 나르는 상자“라고 생각하면 됩니다.
서버는 바깥세상(브라우저, 앱 등)과
끊임없이 데이터를 주고받습니다.
이때 데이터를 담아 보내고 받는 상자가
바로 DTO입니다.
데이터를 담는 것이 목적이므로
DTO는 데이터 클래스로 만들기에 딱 맞습니다.
DTO는 “데이터를 실어 나르는 상자“이고,
데이터 클래스는 그 상자를 만드는 가장 좋은 도구입니다.
Request DTO
먼저 서버가 요청을 “받을 때” 쓰는 상자입니다.
사용자가 회원가입을 한다고 해 봅시다.
브라우저에서 이름과 이메일, 비밀번호를 보냅니다.
서버는 이 값을 담을 상자가 필요합니다.
이것을 요청 DTO(Request DTO)라고 부릅니다.
Request는 “요청”,
즉 “바깥에서 서버로 들어오는 데이터“를 뜻합니다.
data class SignUpRequest(
val name: String,
val email: String,
val password: String,
)
들어온 요청은 이 상자에 담깁니다.
val request = SignUpRequest(
name = "홍길동",
email = "hong@example.com",
password = "1234",
)
이렇게 하면 흩어진 값이
하나로 깔끔하게 묶입니다.
Response DTO
이번에는 서버가 결과를 “돌려줄 때” 쓰는 상자입니다.
회원가입이 끝나면 서버는
결과를 바깥으로 돌려줍니다.
이때 쓰는 상자를
응답 DTO(Response DTO)라고 부릅니다.
Response는 “응답”,
즉 “서버에서 바깥으로 나가는 데이터“를 뜻합니다.
data class SignUpResponse(
val id: Long,
val name: String,
val email: String,
)
여기서 한 가지 눈여겨볼 점이 있습니다.
응답에는 비밀번호가 빠져 있습니다.
받을 때는 비밀번호가 필요했지만,
돌려줄 때 비밀번호를 함께 보내면 위험합니다.
이렇게 상황에 맞게
“필요한 값만 담은 상자“를 따로 만들 수 있습니다.
이것이 요청과 응답을
서로 다른 DTO로 나누는 이유 중 하나입니다.
Domain Object와 DTO 구분하기
여기서 한 걸음 더 나아가 봅시다.
서버 안에는 DTO 말고도
비슷하게 생긴 객체가 하나 더 있습니다.
바로 도메인 객체(Domain Object)입니다.
도메인 객체는
“우리 서비스의 진짜 알맹이가 되는 데이터“입니다.
예를 들어 우리 서비스에서 다루는
“진짜 사용자“를 표현한 것입니다.
// 도메인 객체: 서비스의 핵심 데이터
data class User(
val id: Long,
val name: String,
val email: String,
val password: String,
)
언뜻 보면 DTO와 비슷해 보입니다.
그런데 왜 굳이 나눌까요?
역할이 다르기 때문입니다.
- 도메인 객체 : 서비스 안에서 다루는 핵심 데이터
- DTO : 바깥과 데이터를 주고받는 상자
비유하자면 이렇습니다.
도메인 객체는 “집 안의 물건“이고,
DTO는 “택배로 보낼 때 쓰는 포장 상자“입니다.
물건 그대로를 택배에 던지지 않고,
보낼 것만 골라 상자에 담아 보냅니다.
이렇게 나누면 좋은 점이 있습니다.
첫째, 안전합니다.
비밀번호처럼 민감한 값을
실수로 바깥에 내보내는 일을 막을 수 있습니다.
둘째, 자유롭습니다.
바깥에 보내는 모양(DTO)과
안에서 다루는 모양(도메인 객체)을
서로 눈치 보지 않고 바꿀 수 있습니다.
요청 DTO를 도메인 객체로 바꾸는 흐름은
대략 이렇게 그릴 수 있습니다.
요청 DTO → 도메인 객체 → 응답 DTO
(바깥 → 서버) (서버 내부) (서버 → 바깥)
SignUpRequest → User → SignUpResponse
copy를 배웠으니
변환의 느낌도 살짝 맛볼 수 있습니다.
val request = SignUpRequest("홍길동", "hong@example.com", "1234")
// 요청 DTO의 값을 이용해 도메인 객체를 만든다
val user = User(
id = 1L,
name = request.name,
email = request.email,
password = request.password,
)
// 도메인 객체에서 필요한 값만 골라 응답 DTO를 만든다
val response = SignUpResponse(
id = user.id,
name = user.name,
email = user.email, // 비밀번호는 담지 않음
)
지금은 이 흐름을
완벽히 이해하지 못해도 괜찮습니다.
들어올 때는 요청 DTO,
안에서는 도메인 객체,
나갈 때는 응답 DTO.
이 큰 그림만 기억해 두면 충분합니다.
DTO를 실제 스프링 서버에서
어떻게 주고받는지는 33장에서 깊이 다룹니다.
지금은 “데이터 클래스가 DTO를 만드는 데
아주 잘 어울린다“는 점만 알아 두면 됩니다.
9장을 마치며
이 장에서 우리는 다음을 배웠습니다.
class앞에data를 붙이면
데이터를 담는 클래스가 된다는 점- 데이터 클래스는
toString,equals,hashCode를 자동으로 만들어 준다는 점 - 자바의 POJO에서 길게 써야 했던 코드를
코틀린은 한 줄로 끝낸다는 점 copy로 원본을 지키면서
값이 바뀐 새 객체를 안전하게 만드는 방법- 구조 분해와
componentN으로
값을 한 번에 꺼내는 방법 - 백엔드에서 요청 DTO, 응답 DTO,
도메인 객체를 나누어 쓰는 이유
특히 두 가지는 앞으로 계속 나옵니다.
“데이터는 data class로”, 그리고 “DTO와 도메인 객체는 나눈다“입니다.
다음 장에서는
값을 여러 개 묶어 다루는 방법을 이어서 배워 보겠습니다.